iT邦幫忙

2021 iThome 鐵人賽

DAY 29
0
Modern Web

你渴望連結嗎?將 Web 與硬體連上線吧!系列 第 29

D28 - 「來互相傷害啊!」:粗乃玩搖桿!

  • 分享至 

  • xImage
  •  

本系列文已改編成書「Arduino 自造趣:結合 JavaScript x Vue x Phaser 輕鬆打造個人遊戲機」,本書改用 Vue3 與 TypeScript 全面重構且加上更詳細的說明,

在此感謝 iT 邦幫忙、博碩文化與編輯小 p 的協助,歡迎大家前往購書,鱈魚在此感謝大家 (。・∀・)。

若想 DIY 卻不知道零件去哪裡買的讀者,可以參考此連結 。( •̀ ω •́ )✧


先建立遊戲組件,以便加入後續內容。

src\components\window-app-cat-vs-dog\game-scene.vue <template lang="pug">

.game-scene.w-600px.h-800px(:id='`game-scene-${id}`')

src\components\window-app-cat-vs-dog\game-scene.vue <script>

/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 *
 * @typedef {import('@/types/type').PinInfo} PinInfo
 * @typedef {import('@/types/type').PinCapability} PinCapability
 */

import { mapState } from 'vuex';
import Phaser from 'phaser';

export default {
  name: 'GameScene',
  data() {
    return {
      /** @type {Phaser.Game} */
      game: null,
    };
  },
  mounted() {
    this.initGame();
  },
  methods: {
    /** 初始化遊戲 */
    initGame() {
      /** @type {Phaser.Types.Core.GameConfig} */
      const config = {
        type: Phaser.WEBGL,
        width: 600,
        height: 800,
        parent: `game-scene-${this.id}`,
        scene: [],
        backgroundColor: '#FFF',
        disableContextMenu: true,

        physics: {
          default: 'arcade',
          arcade: {
            // debug: true,
          },
        },
      };

      this.game = new Phaser.Game(config);
    },
  },
};

建立視窗,打開 DevTools,應該會在 Console 中看到以下訊息。

Untitled

這就表示我們成功載入 Phaser 了!

接下來讓我們加入搖桿並完成設定欄位吧。

認識搖桿

先來認識新朋友,相信任何有過遊戲機的玩家應該都有用過。

沒用過搖桿,也有看過搖桿走路。ᕕ( ゚ ∀。)ᕗ

2021-09-17 23.11.59.jpg

D28 - 認識搖桿.png

D28 - 認識搖桿訊號 (2).png

兩個可變電阻剛好相交 90 度角,如此便可以表示一個平面上的運動。

接下來讓我們組一個電路,實際看看搖桿模組的訊號。

組電路之前一樣先來檢查搖桿功能是否正常。

測試搖桿功能

基本上概念等測試「可變電阻」與「按鈕」。

電阻部分

D28 - 測試搖桿可變電阻功能.png

按鈕部分

D28 - 測試搖桿按鈕功能.png

電路實驗

透過先前建立的「數位 I/O 視窗」與「類比輸入視窗」來看看訊號吧!

首先完成電路接線。

D28 - 搖桿電路.png

接著建立視窗實測看看。

D28 - 查看搖桿訊號內容.gif

建立搖桿物件

鱈魚:「讓我們回到 D12 與 D15 一樣,開始解析數位訊號與類比訊號吧!」

電子助教:「同一個段子用兩次會沒人要看喔 ...('◉◞⊖◟◉` )」

鱈魚:「窩錯惹,請大家不要離開 ( ´•̥̥̥ ω •̥̥̥` )」

與 D25 時建立按鈕物件一樣,來做一個搖桿物件吧!

建立 joy-stick.js 用於搖桿訊號轉換、抽象化,方便應用。

  • 給定腳位、模式、收發器,自動處理 Port 轉換、類比訊號、數位訊號監聽等等邏輯。
  • 內部引用 button.js,並轉發所有按鈕事件。
  • 自動回報 XY 軸類比數值。

src\script\electronic-components\joy-stick.js

/**
 * @typedef {import('EventEmitter2').Listener} Listener
 * 
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 *
 * @typedef {import('@/types/type').PinInfo} PinInfo
 * @typedef {import('@/script/firmata/firmata').DigitalResponseMessage} DigitalResponseMessage
 * @typedef {import('@/script/firmata/firmata').AnalogResponseMessage} AnalogResponseMessage
 * @typedef {import('@/script/electronic-components/Button').ConstructorParams} ButtonParams
 *
 * @typedef {Object} ConstructorParams
 * @property {PortTransceiver} transceiver Port 收發器
 * @property {Object} analogPinMap 類比腳位映射表
 * @property {AxisOptions} xAxis X 軸設定
 * @property {AxisOptions} yAxis Y 軸設定
 * @property {Object} btn 按鈕設定
 * @property {PinInfo} btn.pin 指定腳位
 * @property {number} btn.mode 按鈕腳位模式
 * 
 * @typedef {Object} AxisOptions 軸設定
 * @property {PinInfo} pin 指定腳位
 * @property {number} [origin] 原點。搖桿無動作時,類比數值基準點。
 * @property {number} [threshold] 閥值。origin 正負 threshold 之內的數值,視為沒有動作。
 * @property {boolean} [isReverse] 軸反轉
 * 
 */

import EventEmitter2 from 'eventemitter2';
import { findLast, throttle } from 'lodash-es';

import { PinMode } from '@/script/utils/firmata.utils';

import Button from './button';

const axisOptionsDefault = {
  origin: 510,
  threshold: 20,
  isReverse: false,
};

/** 
 * 基本搖桿
 */
export default class extends EventEmitter2.EventEmitter2 {
  /** 目前軸類比數值 */
  axesValue = {
    x: 0,
    y: 0,
  };

  /** @type {PortTransceiver} */
  portTransceiver = null;
  analogPinMap = {};

  /** X 軸設定
   * @type {AxisOptions}
   */
  xAxis = null;
  /** Y 軸設定
   * @type {AxisOptions}
   */
  yAxis = null;

  /** 按鈕物件
   * @type {Button}
   */
  btn = null;

  /** 數值回報監聽器
   * @type {Listener}
   */
  listener = null;

  throttle = {
    setAxesValue: null,
  };

  /**
   * @param {ConstructorParams} params
   */
  constructor(params) {
    super();

    const {
      transceiver = null,
      analogPinMap = null,
      xAxis, yAxis, btn
    } = params;

    if (!transceiver) throw new Error(`transceiver 必填`);
    if (!analogPinMap) throw new Error(`analogPinMap 必填`);
    if (!xAxis?.pin) throw new Error(`xAxis.pin 必填`);
    if (!yAxis?.pin) throw new Error(`yAxis.pin 必填`);
    if (!btn?.pin) throw new Error(`btn.pin 必填`);

    // 儲存變數
    this.portTransceiver = transceiver;
    this.analogPinMap = analogPinMap;
    this.xAxis = {
      ...axisOptionsDefault, ...xAxis
    }
    this.yAxis = {
      ...axisOptionsDefault, ...yAxis
    }

    /** 初始化按鈕物件 */
    this.btn = new Button({
      ...btn,
      transceiver,
    });
    /** 將所有 btn 事件轉送出去 */
    this.btn.onAny((event, value) => this.emit(event, value));

    /** 建立 throttle 功能 */
    this.throttle = {
      setAxesValue: throttle((...params) => {
        this.setAxesValue(...params);
      }, 100),
    }

    this.init();
  }

  /** 初始化
   * 進行腳位設定、啟動監聽器
   */
  async init() {
    const xPinNum = this.xAxis.pin.number;
    const yPinNum = this.yAxis.pin.number;

    this.portTransceiver.addCmd('setMode', {
      pin: xPinNum,
      mode: PinMode.ANALOG_INPUT,
    });
    this.portTransceiver.addCmd('setMode', {
      pin: yPinNum,
      mode: PinMode.ANALOG_INPUT,
    });

    this.listener = this.portTransceiver.on('data:analogMessage', (data) => {
      this.handleData(data);
    }, { objectify: true });
  }
  /** 銷毀所有物件、監聽器 */
  destroy() {
    this.btn.destroy();
    this.listener.off();
    this.removeAllListeners();
  }

  /** 處理類比訊號數值
   * @param {AnalogResponseMessage[]} data
   */
  handleData(data) {
    const { xAxis, yAxis, analogPinMap } = this;

    let x = 0;
    let y = 0;

    /** 取得 X 軸類比資料 */
    const xVal = findLast(data, ({ analogPin }) => {
      const mapNum = analogPinMap[xAxis.pin.number];
      return mapNum === analogPin
    });
    if (xVal) {
      x = this.calcAxisValue(xVal.value, xAxis)
    }

    /** 取得 Y 軸類比資料 */
    const yVal = findLast(data, ({ analogPin }) => {
      const mapNum = analogPinMap[yAxis.pin.number];
      return mapNum === analogPin
    });
    if (yVal) {
      y = this.calcAxisValue(yVal.value, yAxis)
    }

    this.throttle.setAxesValue({
      x, y,
    });
  }

  /** 儲存軸向類比數值 */
  setAxesValue({ x, y }) {
    this.axesValue.x = x;
    this.axesValue.y = y;

    this.emit('data', this.axesValue);
  }
  /** 取得軸向類比數值 */
  getAxesValue() {
    return this.axesValue;
  }
  /** 取得按鈕數值 */
  getBtnValue() {
    return this.btn.getValue();
  }

  /** 將類比數值轉換為搖桿軸資料
   * @param {number} value
   * @param {AxisOptions} options 
   */
  calcAxisValue(value, options) {
    const { origin, threshold, isReverse } = options;

    /** 
     * 需要設定 threshold,因為實際上搖桿回到中點時,
     * 類比數值並非完全靜止,可能會在正負 1 或 2 些微浮動,
     * 如果判斷搖桿靜止是用單一數值判斷,容易誤判,
     * 所以透過 threshold,以範圍來判斷,就可以解決誤判問題。
     */
    const delta = origin - value;
    if (Math.abs(delta) < threshold) {
      return 0;
    }

    return isReverse ? delta * -1 : delta;
  }

}

完成設定欄位

了解搖桿後,現在我們知道總共需要設定 3 個腳位,讓我們回到 window-app-cat-vs-dog.vue 完成設定欄位選項。

  • data()
    • 加入 xPinyPinbtnPin
  • computed
    • 加入 supportPullupPins() 列舉支援上拉輸入腳位清單
    • 加入 supportAnalogInputPins() 列舉支援類比輸入腳位清單
    • 完成 isSettingOk() 程式。
  • watch
    • 偵測 xPinyPinbtnPin 變化,並呼叫 handlePinSelect()

src\components\window-app-cat-vs-dog\window-app-cat-vs-dog.vue <script>

// ...

import { PinMode } from '@/script/utils/firmata.utils';
const { INPUT_PULLUP, ANALOG_INPUT } = PinMode;

export default {
  name: 'WindowAppCatVsDog',
  // ...
  data() {
    return {
      xPin: null,
      yPin: null,
      btnPin: null,
    };
  },
  computed: {
    ...mapState({
      boardPins: (state) => state.board.info.pins,
    }),

    /** 支援上拉輸入功能腳位 */
    supportPullupPins() {
      /** @type {PinInfo[]} */
      const boardPins = this.boardPins;

      return boardPins.filter((pin) =>
        pin.capabilities.some((capability) => INPUT_PULLUP === capability.mode)
      );
    },

    /** 支援類比輸入功能腳位 */
    supportAnalogInputPins() {
      /** @type {PinInfo[]} */
      const boardPins = this.boardPins;

      return boardPins.filter((pin) =>
        pin.capabilities.some((capability) => ANALOG_INPUT === capability.mode)
      );
    },

    /** 設定欄位是否完成 */
    isSettingOk() {
      return this.xPin && this.yPin && this.btnPin;
    },
  },
	watch: {
    xPin(newVal, oldVal) {
      this.handlePinSelect(newVal, oldVal);
    },
    yPin(newVal, oldVal) {
      this.handlePinSelect(newVal, oldVal);
    },
    btnPin(newVal, oldVal) {
      this.handlePinSelect(newVal, oldVal);
    },
  },
  // ...
};

src\components\window-app-cat-vs-dog\window-app-cat-vs-dog.vue <template lang="pug">

base-window.window-app-cat-vs-dog(
  :pos='pos',
  header-icon='r_videogame_asset',
  body-class='c-col',
  title='貓狗大戰'
)
  .h-full.overflow-hidden
    game-scene

    transition(name='fade-up')
      .setting-form(v-if='!isSettingOk')
        .form-section
          .form-item.mb-20px
            q-icon.mr-10px(name='r_gamepad', size='20px')
            .text-18px.font-700
              | 設定控制器

          .form-item
            .text-16px.w-200px
              | X 軸訊號
            base-select-pin.w-full(
              v-model='xPin',
              :pins='supportAnalogInputPins',
              color='light-green-4',
              placeholder='點擊選擇',
              @err='handleErr'
            )

          .form-item
            .text-16px.w-200px
              | Y 軸訊號
            base-select-pin.w-full(
              v-model='yPin',
              :pins='supportAnalogInputPins',
              color='light-green-4',
              placeholder='點擊選擇',
              @err='handleErr'
            )

          .form-item
            .text-16px.w-200px
              | 按鈕訊號
            base-select-pin.w-full(
              v-model='btnPin',
              :pins='supportPullupPins',
              color='light-green-4',
              placeholder='點擊選擇',
              @err='handleErr'
            )

完成後應該會長這樣。

Untitled

將著將腳位透過 props 傳入 game-scene.vue 組件吧。

src\components\window-app-cat-vs-dog\game-scene.vue <script>

// ...

export default {
  name: 'GameScene',
  props: {
    xPin: {
      type: Object,
      default() {
        return null;
      },
    },
    yPin: {
      type: Object,
      default() {
        return null;
      },
    },
    btnPin: {
      type: Object,
      default() {
        return null;
      },
    },
  },
  // ...
};

window-app-cat-vs-dog.vue 模板中的 game-scene.vue 也要記得加入參數。

src\components\window-app-cat-vs-dog\window-app-cat-vs-dog.vue <template lang="pug">

base-window.window-app-cat-vs-dog(
  :pos='pos',
  header-icon='r_videogame_asset',
  body-class='c-col',
  title='貓狗大戰'
)
  .h-full.overflow-hidden
    game-scene(:x-pin='xPin', :y-pin='yPin', :btn-pin='btnPin')
		
		// ...

接著偵測是否所有的腳位都設定完成,完成後進行搖桿初始化。

  • data() 新增 joyStick,儲存搖桿物件。
  • 從 Vuex 中取得 portTransceiveranalogPinMap
  • 刪除 mounted() 內的 this.initGame(),改由其他程式負責呼叫初始化。
  • methods() 新增 initController() ,用於初始化搖桿。
  • created() 使用 $watch 進行所有腳位偵測與初始化。
  • beforeDestroy() 銷毀所有物件。

src\components\window-app-cat-vs-dog\game-scene.vue <script>

// ...

import JoyStick from '@/script/electronic-components/joy-stick';

import { PinMode } from '@/script/utils/firmata.utils';
const { INPUT_PULLUP } = PinMode;

export default {
  name: 'GameScene',
  // ...
  data() {
    return {
      /** @type {Phaser.Game} */
      game: null,

      /** @type {JoyStick} */
      joyStick: null,
    };
  },
	computed: {
    ...mapState({
      /** @type {PortTransceiver} */
      portTransceiver: (state) => state.core.transceiver,
      analogPinMap: (state) => state.board.info.analogPinMap,
    }),
  },
  // ...
	created() {
    // 同時偵測所有 Pin
		this.$watch(
      () => {
        return [this.xPin, this.yPin, this.btnPin];
      },
      ([xPin, yPin, btnPin]) => {
        // 所有腳位都設定完成才初始化
        if (!xPin || !yPin || !btnPin) return;

        this.initController();
        this.initGame();
      }
    );
  },
  mounted() {
    // 清空
  },
  beforeDestroy() {
    this.game?.destroy?.();
    this.joyStick?.destroy?.();
  },
  methods: {
    // ...

    /** 初始化搖桿 */
    initController() {
      this.joyStick = new JoyStick({
        transceiver: this.portTransceiver,
        analogPinMap: this.analogPinMap,
        xAxis: {
          pin: this.xPin,
        },
        yAxis: {
          pin: this.yPin,
        },
        btn: {
          pin: this.btnPin,
          mode: INPUT_PULLUP,
        },
      });
    },
  },
};

忽然發現建立視窗時有個錯誤。

Untitled

這是因為忘記使用 injectprovide 進來的 id 取出來,就直接使用 id 造成的錯誤。

修正一下這個問題。

src\components\window-app-cat-vs-dog\game-scene.vue <script>

// ...

export default {
  name: 'GameScene',
  // ...
  inject: ['id'],
  // ...
};

完成控制器設定!

D28 - 完成選擇控制器設定.gif

加入監聽事件看看搖桿物件有沒有正常作用吧。

initController() 中加入 onAny()

src\components\window-app-cat-vs-dog\game-scene.vue <script>

// ...

import JoyStick from '@/script/electronic-components/joy-stick';

import { PinMode } from '@/script/utils/firmata.utils';
const { INPUT_PULLUP } = PinMode;

export default {
  name: 'GameScene',
  // ...
  methods: {
    // ...

    /** 初始化搖桿 */
    initController() {
      this.joyStick = new JoyStick({
        // ...
      });

			this.joyStick.onAny((event, value) => {
        if (event === 'data') {
          const { x, y } = value;
          console.log(`[ joyStick on ${event} ] value : `, x, y);
          return;
        }

        console.log(`[ joyStick on ${event} ] value : `, value);
      });
    },
  },
};

D28 - 搖桿物件功能正常.gif

搖桿功能正常!一起玩搖桿吧!✧*。٩(ˊᗜˋ*)و✧*。

總結

  • 認識搖桿元件
  • 建立搖桿物件
  • 完成控制器設定
  • 成功取得搖桿資料

以上程式碼已同步至 GitLab,大家可以前往下載:

GitLab - D28


上一篇
D27 - 「來互相傷害啊!」:運籌帷幄
下一篇
D29 - 「來互相傷害啊!」:天時地利,建立 Phaser Scene
系列文
你渴望連結嗎?將 Web 與硬體連上線吧!33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
juck30808
iT邦研究生 1 級 ‧ 2021-10-12 18:34:12

恭喜即將完賽!!!

鱈魚 iT邦新手 1 級 ‧ 2021-10-12 23:07:39 檢舉

感謝您的支持!( ´ ▽ ` )ノ

我要留言

立即登入留言